Gradle
Build
System
Gradle is not just a build tool — it is a programmable execution engine. Understanding how it works transforms your build from a black box into a precisely tuned, fast, reproducible machine.
What is Gradle?
Gradle is a general-purpose build automation tool built on a directed acyclic graph (DAG) of tasks. It is not specific to Android — it builds Java, Kotlin, C++, Python, and more. The Android Gradle Plugin (AGP) is a set of Gradle plugins and tasks that teach Gradle how to compile, package, test, and sign Android applications.
At its core, Gradle has three phases it always runs through in order: Initialization, Configuration, and Execution. Every performance problem in a slow build can be traced to one of these phases doing too much work. Understanding the boundary between them is the single most important Gradle concept.
compileDebugKotlin, processDebugResources, packageDebugApk. Wires them into the Gradle task graph. AGP version must be compatible with your Gradle version.Build Phases
Every Gradle build runs through exactly three phases in strict order. Code that runs in the wrong phase is the most common source of both build errors and performance problems. Getting this right is foundational.
Gradle reads settings.gradle.kts and determines which projects participate in the build. For a single-project build this is trivial. For a 50-module project, Gradle discovers and instantiates all project objects. Each module's buildSrc is also compiled here. Nothing from your build scripts runs yet.
Every build.gradle.kts file in every participating module is executed — even for tasks you are not running. This builds the complete task dependency graph. This is the most commonly misunderstood phase. Any I/O, network calls, or slow logic in a build script slows down every single build. Use the Configuration Cache to skip this phase on subsequent builds.
Only the tasks required by the requested task and its dependencies are executed, in dependency order. Tasks are skipped if: their inputs and outputs haven't changed (incremental build), their output is available from cache (build cache hit), or they are marked UP-TO-DATE. This is where the actual compilation, resource processing, and packaging happens.
Critical rule: Build scripts run during Configuration, not Execution. Never do I/O, file reading, network calls, or expensive computations directly in a build.gradle.kts script. Put that logic inside a tasks.register { doLast { ... } } block so it runs during Execution — and only when needed.
// ✗ WRONG — this runs during Configuration on EVERY build, even `gradle tasks` val version = File("version.txt").readText().trim() // IO in config phase val gitHash = Runtime.exec("git rev-parse --short HEAD") // process in config phase // ✓ CORRECT — runs during Execution, only when this task is needed tasks.register("generateBuildConfig") { doLast { val version = File("version.txt").readText().trim() val gitHash = ProcessBuilder("git", "rev-parse", "--short", "HEAD") .start().inputStream.bufferedReader().readLine() println("Version: $version, Hash: $gitHash") } } // ✓ ALSO CORRECT — lazy provider, evaluated only when needed val versionProvider = providers.fileContents(layout.projectDirectory.file("version.txt")) .asText.map { it.trim() }
Task lifecycle within Execution
Every Gradle task has three action blocks: doFirst { } runs before the task's main action, the main action (defined by the task type), and doLast { } runs after. Tasks also declare their inputs and outputs — Gradle uses these to determine UP-TO-DATE status and to cache outputs.
@CacheableTask abstract class GenerateVersionTask : DefaultTask() { @get:InputFile @get:PathSensitive(PathSensitivity.RELATIVE) abstract val versionFile: RegularFileProperty @get:Input abstract val buildNumber: Property<Int> @get:OutputFile abstract val outputFile: RegularFileProperty @TaskAction fun generate() { val version = versionFile.get().asFile.readText().trim() outputFile.get().asFile.writeText("$version.${buildNumber.get()}") } // Gradle tracks inputs: if versionFile and buildNumber unchanged → UP-TO-DATE // @CacheableTask: outputs cached remotely → other machines skip this task }
Groovy vs Kotlin DSL
Gradle historically used a Groovy-based DSL (.gradle files). Modern Android projects use the Kotlin DSL (.gradle.kts files). The switch matters for two reasons: type safety catches configuration errors at compile time, and IDE tooling (auto-complete, navigation, refactoring) works properly.
// ── Groovy (build.gradle) ────────────────────────────── android { compileSdkVersion 34 defaultConfig { applicationId "com.example.app" minSdkVersion 24 targetSdkVersion 34 versionCode 1 versionName "1.0" } buildTypes { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt') } } } // ── Kotlin DSL (build.gradle.kts) ────────────────────── android { compileSdk = 34 defaultConfig { applicationId = "com.example.app" minSdk = 24 targetSdk = 34 versionCode = 1 versionName = "1.0" } buildTypes { release { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } }
Multi-module Projects
A multi-module project divides your app into separate Gradle modules — each compiled independently, each with its own dependency graph. This is the single most impactful architectural decision for build performance in large Android projects.
The key insight: Gradle can build independent modules in parallel. If :feature:home and :feature:settings don't depend on each other, they compile simultaneously. A well-modularized 50-module project can be dramatically faster than a monolith — even if the total code is the same.
pluginManagement { repositories { google() mavenCentral() gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google(); mavenCentral() } } include(":app") include(":feature:home") include(":feature:settings") include(":feature:profile") include(":core:ui") include(":core:network") include(":core:data") include(":core:common") include(":core:model") include(":core:testing")
Module types matter: Use com.android.library for modules with Android resources/code, com.android.application only for the final APK module, and plain org.jetbrains.kotlin.jvm for pure Kotlin modules with no Android dependencies. Pure Kotlin modules compile faster — no AAPT2, no manifest merging, no resource compilation.
Sharing Code Across Modules
The challenge in multi-module projects is managing what is shared and how. There are three primary mechanisms, each with different trade-offs around coupling, build performance, and IDE support.
// ── 1. Module dependency (recommended) ────────────────────────── // In :feature:home/build.gradle.kts dependencies { implementation(project(":core:ui")) // compile-time + runtime implementation(project(":core:data")) testImplementation(project(":core:testing")) // api() vs implementation(): // implementation = dependency NOT exposed to consumers (preferred) // api = dependency IS exposed — creates a transitive dependency } // ── 2. buildSrc — shared build logic ──────────────────────────── // buildSrc/src/main/kotlin/Dependencies.kt object Versions { const val KOTLIN = "1.9.0" const val COMPOSE = "1.5.0" } // Automatically available in all build scripts — but slow to change // Changing ANY file in buildSrc invalidates ALL configuration caches // ── 3. Included build (better than buildSrc) ───────────────────── // settings.gradle.kts includeBuild("build-logic") // dedicated build for convention plugins
api() vs implementation() — the critical distinction
This is one of the most impactful decisions in multi-module builds. Using api() when you should use implementation() creates unnecessary recompilation cascades. When module A uses api(project(":core:model")), any module that depends on A also transitively sees :core:model — and must recompile when :core:model changes.
// :core:network module dependencies { api(libs.retrofit) // ✗ Retrofit types leak into all consumers of :core:network // Changing Retrofit version → recompile :feature:home, :feature:settings, ALL consumers implementation(libs.retrofit) // ✓ Retrofit is an internal detail // Changing Retrofit → only :core:network recompiles } // Only use api() when the type IS your public API: dependencies { api(project(":core:model")) // ✓ Your public functions return/accept model types implementation(project(":core:network")) // ✓ Internal network calls — not exposed }
Convention Plugins
In a 20-module project, you'll find yourself copying the same android { compileSdk = 34; kotlinOptions { jvmTarget = "17" } } block into every build file. Convention plugins solve this: they are precompiled Gradle plugins that encode your project's build conventions, applied to modules that follow that convention.
Convention plugins live in an included build called build-logic. Unlike buildSrc, changes to build-logic plugins only invalidate modules that apply them — not the entire project configuration cache.
// Project structure: // ├── build-logic/ // │ ├── convention/ // │ │ ├── build.gradle.kts // │ │ └── src/main/kotlin/ // │ │ ├── AndroidApplicationConventionPlugin.kt // │ │ ├── AndroidLibraryConventionPlugin.kt // │ │ ├── AndroidFeatureConventionPlugin.kt // │ │ └── AndroidComposeConventionPlugin.kt // │ └── settings.gradle.kts // └── settings.gradle.kts ← includeBuild("build-logic") // build-logic/convention/build.gradle.kts plugins { `kotlin-dsl` } dependencies { compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) }
class AndroidLibraryConventionPlugin : Plugin<Project> { override fun apply(target: Project) { with(target) { with(pluginManager) { apply("com.android.library") apply("org.jetbrains.kotlin.android") } extensions.configure<LibraryExtension> { configureKotlinAndroid(this) // shared extension function defaultConfig.targetSdk = 34 // No need to repeat compileSdk, jvmTarget in every module } val libs = extensions.getByType<VersionCatalogsExtension>().named("libs") dependencies { add("implementation", libs.findLibrary("timber").get()) add("testImplementation", libs.findLibrary("junit").get()) } } } } // shared extension function used by all convention plugins: fun Project.configureKotlinAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) { commonExtension.apply { compileSdk = 34 defaultConfig { minSdk = 24 } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget = "17" } } }
// Instead of 50 lines of repeated configuration: plugins { alias(libs.plugins.nowinandroid.android.feature) // convention plugin! alias(libs.plugins.nowinandroid.android.library.compose) } android { namespace = "com.example.feature.home" } dependencies { implementation(project(":core:data")) implementation(project(":core:ui")) // compileSdk, minSdk, jvmTarget, test deps — all from convention plugin }
Build Caching
Gradle has three caching layers. Understanding all three — and the difference between them — is essential for fast builds. Each layer operates on different inputs and has different scope and lifetime.
clean buildsorg.gradle.caching=trueorg.gradle.configuration-cache=trueCache key = hash of all task inputs. For a task to be cache-hit on another machine, its input hash must match exactly. Inputs include: source files, dependencies, compiler flags, JVM version, Gradle version, AGP version. Non-deterministic inputs (timestamps, absolute paths) break cacheability. Always use @PathSensitive and avoid absolute paths in task inputs.
# gradle.properties org.gradle.caching=true # local build cache org.gradle.configuration-cache=true # config cache (Gradle 8+) org.gradle.parallel=true # parallel module execution org.gradle.daemon=true # keep JVM alive between builds org.gradle.jvmargs=-Xmx4g -XX:+UseG1GC # Gradle daemon heap # Remote cache (Gradle Enterprise / Develocity) com.gradle.develocity.url=https://ge.mycompany.com com.gradle.develocity.build-scan.upload-in-background=true
Configuration Cache
The Configuration Cache is one of the most impactful performance improvements in recent Gradle history. It serializes the entire task graph after the Configuration phase. On subsequent builds where configuration inputs (build scripts, settings) haven't changed, Gradle restores the task graph from cache — completely skipping the Configuration phase.
// ✗ VIOLATION — accessing project at execution time tasks.register("myTask") { doLast { val version = project.version // project not accessible at execution time } } // ✓ CORRECT — capture at configuration time tasks.register("myTask") { val version = project.version.toString() // captured at config time, serialized doLast { println(version) // uses captured value at execution time } } // ✗ VIOLATION — using System.getenv() in a task action directly tasks.register("badTask") { doLast { println(System.getenv("CI")) } // not serializable } // ✓ CORRECT — use Gradle providers tasks.register("goodTask") { val ci = providers.environmentVariable("CI") // lazy, serializable provider doLast { println(ci.orNull) } }
Parallel Execution
With org.gradle.parallel=true, Gradle can execute independent tasks from different modules simultaneously. The number of parallel workers defaults to the number of CPU cores minus 1. This is the biggest single build-time lever for multi-module projects.
Typical parallel build timeline (8 modules, 8 cores)
Maximizing parallelism: The more independent your module graph is, the more parallelism is possible. Avoid unnecessary dependencies between modules. Keep your dependency graph as wide (many modules at the same level) as possible, rather than deep (long chains). Each extra layer of depth adds to the critical path length.
Performance Tips
A comprehensive checklist of Gradle performance optimizations — ordered by impact, with the settings that deliver the biggest improvements first.
| Optimization | Impact | How | Notes |
|---|---|---|---|
| Configuration Cache | HIGH | org.gradle.configuration-cache=true | Skips entire Config phase on subsequent builds. 5–15s saved per build. |
| Parallel execution | HIGH | org.gradle.parallel=true | Linear improvement with number of independent modules. Biggest gain in multi-module projects. |
| Local build cache | HIGH | org.gradle.caching=true | Survives clean builds. Switching branches restores from cache instantly. |
| Daemon warm JVM | MEDIUM | org.gradle.daemon=true (default) | Keeps the Gradle JVM alive. First build after reboot is slow; subsequent builds skip JVM startup. |
| Increase daemon heap | MEDIUM | org.gradle.jvmargs=-Xmx4g | Prevents GC pressure in Gradle daemon. 4GB is usually sufficient. Don't exceed available RAM. |
| Use implementation not api | HIGH | Code discipline | Prevents unnecessary recompilation cascade when library changes. Reduces the blast radius of changes. |
| Modularize aggressively | HIGH | Architecture | More modules = more parallelism. Target: no module takes more than 30s to compile independently. |
| Kotlin incremental compilation | MEDIUM | kotlin.incremental=true (default) | Only recompiles files that changed and files that depend on them. Works best with module boundaries. |
| kapt → KSP migration | HIGH | Replace kapt with ksp | KSP is 2× faster than KAPT for annotation processing. Migrate Room, Hilt, Moshi to their KSP versions. |
| Non-transitive R classes | MEDIUM | android.nonTransitiveRClass=true | Each module only sees its own R class. Reduces the R class size and recompilation scope. |
| Avoid dynamic versions | MEDIUM | Never use 1.0.+ in deps | Dynamic versions require network check on every build. Always pin exact versions. |
| Remote build cache | HIGH | Gradle Enterprise / Develocity | First build on a new machine hits CI cache. Developer never compiles what CI already compiled. |
# Execution org.gradle.daemon=true org.gradle.parallel=true org.gradle.caching=true org.gradle.configuration-cache=true org.gradle.configuration-cache-problems=warn # warn instead of fail during migration # JVM tuning — adjust maxHeap to 50% of your RAM org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC \ -XX:SoftRefLRUPolicyMSPerMB=0 -XX:+HeapDumpOnOutOfMemoryError # Kotlin kotlin.incremental=true kotlin.incremental.useClasspathSnapshot=true # ABI-based incremental compilation kotlin.build.report.output=file # Android android.nonTransitiveRClass=true android.enableJetifier=false # if you've migrated fully to AndroidX android.defaults.buildfeatures.buildconfig=false # disable unused features android.defaults.buildfeatures.aidl=false
Version Catalogs
Version Catalogs (libs.versions.toml) are the modern, type-safe way to manage dependencies across a multi-module project. They replace buildSrc/Dependencies.kt, Kotlin objects with version constants, and ext properties — all of which have worse IDE support and caching behavior.
[versions] agp = "8.3.0" kotlin = "1.9.22" ksp = "1.9.22-1.0.17" compose-bom = "2024.02.00" hilt = "2.50" room = "2.6.1" retrofit = "2.9.0" coroutines = "1.7.3" lifecycle = "2.7.0" [libraries] # Compose — using BOM for consistent versions compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" } compose-ui = { group = "androidx.compose.ui", name = "ui" } compose-material3 = { group = "androidx.compose.material3", name = "material3" } # Hilt hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } # Room room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } [bundles] # Declare groups of libraries applied together room = ["room-runtime", "room-ktx"] compose = ["compose-ui", "compose-material3"] [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
dependencies {
implementation(platform(libs.compose.bom)) // BOM manages all compose versions
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.bundles.room) // bundle: room-runtime + room-ktx
ksp(libs.room.compiler)
// Type-safe: libs.compose.ui auto-completes in IDE, no magic strings
}
Custom Tasks
Gradle's power comes from its extensibility. Custom tasks let you add project-specific build steps — generating code, validating configurations, publishing artifacts, running checks — as first-class citizens in your build that benefit from caching, UP-TO-DATE checking, and parallel execution.
// ── Pattern 1: Typed task class (recommended for reuse) ──────── @CacheableTask abstract class GenerateApiModelsTask : DefaultTask() { @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) abstract val schemaDir: DirectoryProperty @get:OutputDirectory abstract val outputDir: DirectoryProperty @TaskAction fun generate() { schemaDir.get().asFile.walkTopDown() .filter { it.name.endsWith(".json") } .forEach { schema -> generateModelFrom(schema, outputDir.get().asFile) } } } // Register and wire into the build val generateModels = tasks.register<GenerateApiModelsTask>("generateApiModels") { schemaDir.set(layout.projectDirectory.dir("schemas")) outputDir.set(layout.buildDirectory.dir("generated/models")) } // Wire generated sources into compilation android.sourceSets["main"].java.srcDir(generateModels) // ── Pattern 2: Verification task ─────────────────────────────── tasks.register("verifyDependencies") { description = "Fails if any dependency uses a dynamic version" group = "verification" doLast { configurations.filter { it.isCanBeResolved }.flatMap { it.dependencies } .filter { dep -> dep.version?.contains("+") == true } .takeIf { it.isNotEmpty() } ?.let { throw GradleException("Dynamic dependencies found: $it") } } } // Run automatically before every build tasks.named("preBuild").configure { dependsOn("verifyDependencies") }
Build Simulator
Simulate different build scenarios and see how caching, parallel execution, and module structure affect total build time.